page.tsx 74 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520
  1. 'use client';
  2. import { useState, useEffect, useCallback, useRef } from 'react';
  3. import { useParams, useRouter } from 'next/navigation';
  4. import { useAuth } from '@/lib/auth-context';
  5. import { assetsApi, commentsApi, AssetWithComments, Asset, Comment, AnnotationData, TranscodeStatus } from '@/lib/api';
  6. import { Avatar } from '@/components/ui/avatar';
  7. import { VideoPlayer } from '@/components/video-player/VideoPlayer';
  8. import { Tool } from '@/components/video-player/AnnotationCanvas';
  9. import { formatTimecode } from '@/lib/format';
  10. const API_BASE = process.env.NEXT_PUBLIC_API_URL || '';
  11. const MAX_ANNOTATIONS = 10;
  12. const STATUS_CONFIG: Record<string, { label: string; colorClass: string; bgClass: string; dotClass: string }> = {
  13. PENDING_REVIEW: { label: 'Pending Review', colorClass: 'text-warning', bgClass: 'badge-warning', dotClass: 'status-dot-pending' },
  14. CHANGES_REQUESTED: { label: 'Changes Requested', colorClass: 'text-warning', bgClass: 'badge-warning', dotClass: 'status-dot-changes' },
  15. APPROVED: { label: 'Approved', colorClass: 'text-success', bgClass: 'badge-success', dotClass: 'status-dot-approved' },
  16. REJECTED: { label: 'Rejected', colorClass: 'text-danger', bgClass: 'badge-danger', dotClass: 'status-dot-rejected' },
  17. };
  18. const TRANSCODE_CONFIG: Record<TranscodeStatus, { label: string; color: string; bg: string; spinner: boolean }> = {
  19. PENDING: { label: 'Queued', color: '#94A3B8', bg: 'rgba(148,163,184,0.08)', spinner: false },
  20. UPLOADING: { label: 'Uploading video…', color: '#60A5FA', bg: 'rgba(96,165,250,0.08)', spinner: true },
  21. PROCESSING: { label: 'Transcoding…', color: '#A78BFA', bg: 'rgba(167,139,250,0.08)', spinner: true },
  22. COMPLETED: { label: 'Ready', color: '#34D399', bg: 'rgba(52,211,153,0.08)', spinner: false },
  23. FAILED: { label: 'Transcode failed', color: '#F87171', bg: 'rgba(248,113,113,0.08)', spinner: false },
  24. UNSUPPORTED_CODEC: { label: 'Unsupported codec', color: '#FBBF24', bg: 'rgba(251,191,36,0.08)', spinner: false },
  25. };
  26. export default function ReviewPage() {
  27. const params = useParams();
  28. const assetId = params.assetId as string;
  29. const { token, user } = useAuth();
  30. const router = useRouter();
  31. const [asset, setAsset] = useState<AssetWithComments | null>(null);
  32. const [comments, setComments] = useState<Comment[]>([]);
  33. const [loading, setLoading] = useState(true);
  34. const [currentTime, setCurrentTime] = useState(0);
  35. const [panelWidth, setPanelWidth] = useState(380);
  36. const [commentPanelCollapsed, setCommentPanelCollapsed] = useState(false);
  37. const [showApproval, setShowApproval] = useState(false);
  38. const [updatingStatus, setUpdatingStatus] = useState(false);
  39. const [newComment, setNewComment] = useState('');
  40. const [submitting, setSubmitting] = useState(false);
  41. const [replyTo, setReplyTo] = useState<Comment | null>(null);
  42. const [showResolved, setShowResolved] = useState(false);
  43. // Drawing state — lifted to page level
  44. const [drawMode, setDrawMode] = useState(false);
  45. const [drawTool, setDrawTool] = useState<Tool>('arrow');
  46. const [drawColor, setDrawColor] = useState('#ef4444');
  47. const [pendingStrokes, setPendingStrokes] = useState<AnnotationData[]>([]);
  48. // The comment we're annotating (null = annotating the main video, not a specific comment)
  49. const [annotatingComment, setAnnotatingComment] = useState<Comment | null>(null);
  50. // Portrait / landscape detection
  51. const [isPortrait, setIsPortrait] = useState(false);
  52. // ── Side-by-side compare mode ────────────────────────────────────────────
  53. const [compareMode, setCompareMode] = useState(false);
  54. const [compareAsset, setCompareAsset] = useState<Asset | null>(null);
  55. const [showComparePicker, setShowComparePicker] = useState(false);
  56. const [projectAssets, setProjectAssets] = useState<Asset[]>([]);
  57. const [compareMismatch, setCompareMismatch] = useState<string | null>(null);
  58. const [compareComments, setCompareComments] = useState<Comment[]>([]);
  59. const [playing, setPlaying] = useState(false);
  60. // Toggle annotation + speech bubble visibility per video in compare mode
  61. const [showMainAnnotations, setShowMainAnnotations] = useState(true);
  62. const [showCompareAnnotations, setShowCompareAnnotations] = useState(true);
  63. // Video element ref so we can seek directly from comment timestamp clicks
  64. const mainVideoRef = useRef<HTMLVideoElement>(null);
  65. const handleCompareSelect = useCallback((compareAssetArg: Asset) => {
  66. setShowComparePicker(false);
  67. setCompareMismatch(null);
  68. const dur1 = asset?.duration ?? 0;
  69. const dur2 = compareAssetArg.duration ?? 0;
  70. const fps = asset?.fps ?? compareAssetArg.fps ?? 30;
  71. const diffFrames = Math.abs(dur1 - dur2) * fps;
  72. if (diffFrames > 5) {
  73. setCompareMismatch(
  74. `Videos differ by ${Math.round(diffFrames)} frames. Cannot compare — timing mismatch.`
  75. );
  76. // Show mismatch banner but don't enter compare mode
  77. setCompareAsset(compareAssetArg);
  78. setCompareMode(true);
  79. return;
  80. }
  81. setCompareAsset(compareAssetArg);
  82. setCompareMode(true);
  83. // Fetch compare asset's own comments for per-video annotations
  84. if (token) {
  85. commentsApi.list(token, compareAssetArg.id).then(({ comments: cc }) => {
  86. setCompareComments(cc);
  87. }).catch(() => setCompareComments([]));
  88. }
  89. }, [asset, token]);
  90. const handleExitCompare = useCallback(() => {
  91. setCompareMode(false);
  92. setCompareAsset(null);
  93. setCompareMismatch(null);
  94. setCompareComments([]);
  95. }, []);
  96. useEffect(() => {
  97. const mq = window.matchMedia('(orientation: portrait)');
  98. setIsPortrait(mq.matches);
  99. const handler = (e: MediaQueryListEvent) => setIsPortrait(e.matches);
  100. mq.addEventListener('change', handler);
  101. return () => mq.removeEventListener('change', handler);
  102. }, []);
  103. const isDraggingRef = useRef(false);
  104. const panelRef = useRef<HTMLDivElement>(null);
  105. const resizeStartRef = useRef<{ x: number; w: number } | null>(null);
  106. // Ref to capture strokes for save callback (avoids closure stale value)
  107. const pendingStrokesRef = useRef<AnnotationData[]>([]);
  108. const annotatingCommentRef = useRef<Comment | null>(null);
  109. // Keep refs in sync with state
  110. useEffect(() => { pendingStrokesRef.current = pendingStrokes; }, [pendingStrokes]);
  111. useEffect(() => { annotatingCommentRef.current = annotatingComment; }, [annotatingComment]);
  112. const fps = asset?.fps ?? 30;
  113. // Derive the current user's project role
  114. const currentUserRole = asset?.project.members.find(m => m.user.id === user?.id)?.role;
  115. const isProjectAdmin = currentUserRole === 'ADMIN';
  116. const isProjectOwner = asset?.project.ownerId === user?.id;
  117. const canComment: boolean | undefined = !!(currentUserRole && currentUserRole !== 'VIEWER');
  118. // ── Poll for transcode progress ───────────────────────────────────────────
  119. const isTranscoding = asset?.transcodeStatus === 'COMPLETED';
  120. const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
  121. useEffect(() => {
  122. if (isTranscoding) {
  123. if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; }
  124. return;
  125. }
  126. if (pollRef.current) return;
  127. pollRef.current = setInterval(async () => {
  128. if (!token) return;
  129. try {
  130. const { asset: updated } = await assetsApi.getStatus(token, assetId);
  131. setAsset(prev => prev ? { ...prev, ...updated } : prev);
  132. } catch {}
  133. }, 2000);
  134. return () => { if (pollRef.current) clearInterval(pollRef.current); };
  135. }, [token, assetId, isTranscoding]);
  136. // Load asset + comments
  137. const loadData = useCallback(async () => {
  138. if (!token) return;
  139. try {
  140. const [{ asset: a }, { comments: c }] = await Promise.all([
  141. assetsApi.get(token, assetId),
  142. commentsApi.list(token, assetId),
  143. ]);
  144. setAsset(a);
  145. setComments(c);
  146. } catch {
  147. router.push('/projects');
  148. } finally {
  149. setLoading(false);
  150. }
  151. }, [token, assetId, router]);
  152. useEffect(() => { loadData(); }, [loadData]);
  153. // ── Panel resize ─────────────────────────────────────────────────────────
  154. const handlePointerMove = useCallback((e: PointerEvent) => {
  155. if (!isDraggingRef.current || !resizeStartRef.current) return;
  156. const dx = e.clientX - resizeStartRef.current.x;
  157. setPanelWidth(Math.max(280, Math.min(600, resizeStartRef.current.w - dx)));
  158. }, []);
  159. const handlePointerUp = useCallback(() => {
  160. isDraggingRef.current = false;
  161. resizeStartRef.current = null;
  162. document.body.style.cursor = '';
  163. }, []);
  164. useEffect(() => {
  165. window.addEventListener('pointermove', handlePointerMove);
  166. window.addEventListener('pointerup', handlePointerUp);
  167. return () => {
  168. window.removeEventListener('pointermove', handlePointerMove);
  169. window.removeEventListener('pointerup', handlePointerUp);
  170. };
  171. }, [handlePointerMove, handlePointerUp]);
  172. const handleResizeStart = (e: React.PointerEvent) => {
  173. e.preventDefault();
  174. isDraggingRef.current = true;
  175. resizeStartRef.current = { x: e.clientX, w: panelWidth };
  176. document.body.style.cursor = 'col-resize';
  177. };
  178. // ── Comment actions ───────────────────────────────────────────────────────
  179. const handleAddComment = async (content: string, timestamp?: number, annotations?: AnnotationData[]) => {
  180. if (!token || !content.trim()) return;
  181. setSubmitting(true);
  182. try {
  183. const { comment } = await commentsApi.create(token, assetId, {
  184. content: content.trim(),
  185. timestamp,
  186. annotations,
  187. parentId: replyTo?.id,
  188. });
  189. if (replyTo) {
  190. setComments(prev => prev.map(c =>
  191. c.id === replyTo.id
  192. ? { ...c, replies: [...(c.replies ?? []), comment] }
  193. : c
  194. ));
  195. } else {
  196. setComments(prev => [...prev, comment]);
  197. }
  198. setNewComment('');
  199. setPendingStrokes([]);
  200. setReplyTo(null);
  201. } catch (err) {
  202. alert(err instanceof Error ? err.message : 'Failed to add comment');
  203. } finally {
  204. setSubmitting(false);
  205. }
  206. };
  207. const handleResolve = async (commentId: string, action: 'approve' | 'reject') => {
  208. if (!token) return;
  209. try {
  210. const { comment } = await commentsApi.resolve(token, commentId, action);
  211. setComments(prev => prev.map(c => c.id === commentId ? comment : c));
  212. } catch (err) {
  213. alert(err instanceof Error ? err.message : 'Failed to update comment');
  214. }
  215. };
  216. const handleRequestResolve = async (commentId: string) => {
  217. if (!token) return;
  218. try {
  219. const { comment } = await commentsApi.requestResolve(token, commentId);
  220. setComments(prev => prev.map(c => c.id === commentId ? comment : c));
  221. } catch (err) {
  222. alert(err instanceof Error ? err.message : 'Failed to request resolve');
  223. }
  224. };
  225. const handleDeleteComment = async (commentId: string) => {
  226. if (!token) return;
  227. // Soft delete — just mark hidden, owner can restore
  228. try {
  229. await commentsApi.delete(token, commentId);
  230. setComments(prev => prev.map(c =>
  231. c.id === commentId ? { ...c, deleted: true } : c
  232. ));
  233. } catch {
  234. alert('Failed to hide comment');
  235. }
  236. };
  237. const handleRestoreComment = async (commentId: string) => {
  238. if (!token) return;
  239. try {
  240. const { comment } = await commentsApi.restoreComment(token, commentId);
  241. setComments(prev => prev.map(c => c.id === commentId ? comment : c));
  242. } catch {
  243. alert('Failed to restore comment');
  244. }
  245. };
  246. // ── Annotation actions ─────────────────────────────────────────────────────
  247. // User clicks "Add annotation" on a comment — enter draw mode, annotate at current time
  248. const handleAddAnnotationClick = (comment: Comment) => {
  249. const existingCount = comment.annotations?.length ?? 0;
  250. if (existingCount >= MAX_ANNOTATIONS) {
  251. alert(`Maximum ${MAX_ANNOTATIONS} annotations per comment.`);
  252. return;
  253. }
  254. setPendingStrokes([]);
  255. setAnnotatingComment(comment);
  256. setDrawMode(true);
  257. };
  258. // Each completed stroke is added to pendingStrokes
  259. const handleStrokeComplete = (stroke: AnnotationData) => {
  260. setPendingStrokes(prev => {
  261. const next = [...prev, stroke];
  262. if (next.length >= MAX_ANNOTATIONS) {
  263. setDrawMode(false);
  264. }
  265. return next;
  266. });
  267. };
  268. // Save pending strokes as annotation on the parent comment (no separate reply)
  269. const handleSaveAnnotations = () => {
  270. const strokes = pendingStrokesRef.current;
  271. const parent = annotatingCommentRef.current;
  272. if (!token || !parent || strokes.length === 0) {
  273. setPendingStrokes([]);
  274. setDrawMode(false);
  275. setAnnotatingComment(null);
  276. return;
  277. }
  278. setSubmitting(true);
  279. setPendingStrokes([]);
  280. setDrawMode(false);
  281. setAnnotatingComment(null);
  282. commentsApi.updateAnnotations(token, parent.id, strokes).then(({ comment }) => {
  283. setComments(prev => prev.map(c => c.id === parent.id ? comment : c));
  284. }).catch(err => alert(err instanceof Error ? err.message : 'Failed to save annotation')).finally(() => setSubmitting(false));
  285. };
  286. // Discard pending strokes
  287. const handleUndoAnnotations = () => {
  288. setPendingStrokes([]);
  289. setDrawMode(false);
  290. setAnnotatingComment(null);
  291. };
  292. // Delete a single annotation from a comment (owner only)
  293. const handleDeleteAnnotation = async (commentId: string, remainingAnnotations: AnnotationData[]) => {
  294. if (!token) return;
  295. try {
  296. const { comment } = await commentsApi.updateAnnotations(token, commentId, remainingAnnotations);
  297. setComments(prev => prev.map(c => c.id === commentId ? comment : c));
  298. } catch {
  299. alert('Failed to delete annotation');
  300. }
  301. };
  302. const handleStatusUpdate = async (status: string) => {
  303. if (!token) return;
  304. setUpdatingStatus(true);
  305. try {
  306. const { asset: updated } = await assetsApi.updateStatus(token, assetId, status);
  307. setAsset(prev => prev ? { ...prev, status: updated.status } : prev);
  308. setShowApproval(false);
  309. } catch {
  310. alert('Failed to update status');
  311. } finally {
  312. setUpdatingStatus(false);
  313. }
  314. };
  315. const handleTimeUpdate = useCallback((time: number) => {
  316. setCurrentTime(time);
  317. }, []);
  318. const handleCommentSeek = useCallback((comment: Comment) => {
  319. const time = comment.timestamp ?? 0;
  320. setCurrentTime(time);
  321. if (mainVideoRef.current) {
  322. mainVideoRef.current.pause();
  323. mainVideoRef.current.currentTime = time;
  324. }
  325. }, []);
  326. const status = asset?.status ?? 'PENDING_REVIEW';
  327. const statusCfg = STATUS_CONFIG[status];
  328. const transcodeCfg = asset ? TRANSCODE_CONFIG[asset.transcodeStatus] : null;
  329. const videoUrl = asset?.hlsPath
  330. ? `${API_BASE}/uploads${asset.hlsPath}`
  331. : asset
  332. ? `${API_BASE}/uploads/${asset.filePath}`
  333. : '';
  334. const allComments = comments.flatMap(c => [c, ...(c.replies ?? [])]);
  335. const visibleComments = comments.filter(c => !c.deleted && (showResolved || !c.resolved));
  336. // Seek to previous/next comment (defined here so they can reference visibleComments)
  337. const handlePrevComment = useCallback(() => {
  338. const ts = visibleComments
  339. .filter(c => c.timestamp != null)
  340. .map(c => c.timestamp as number)
  341. .sort((a, b) => b - a);
  342. const prev = ts.find(t => t < currentTime - 0.3);
  343. if (prev !== undefined) handleCommentSeek({ timestamp: prev } as Comment);
  344. }, [visibleComments, currentTime, handleCommentSeek]);
  345. const handleNextComment = useCallback(() => {
  346. const ts = visibleComments
  347. .filter(c => c.timestamp != null)
  348. .map(c => c.timestamp as number)
  349. .sort((a, b) => a - b);
  350. const next = ts.find(t => t > currentTime + 0.3);
  351. if (next !== undefined) handleCommentSeek({ timestamp: next } as Comment);
  352. }, [visibleComments, currentTime, handleCommentSeek]);
  353. // Only main comments (not replies, not deleted) have annotations that should show on the video
  354. const visibleAnnotations = visibleComments
  355. .filter(c => !c.deleted)
  356. .flatMap(c =>
  357. (c.annotations ?? []).map(ann => ({ annotation: ann, timestamp: c.timestamp ?? 0 }))
  358. );
  359. // Annotations for the compare video — independent per-video data
  360. const compareVisibleComments = compareComments.filter(c => !c.deleted && (showResolved || !c.resolved));
  361. const compareVisibleAnnotations = compareVisibleComments
  362. .filter(c => !c.deleted)
  363. .flatMap(c =>
  364. (c.annotations ?? []).map(ann => ({ annotation: ann, timestamp: c.timestamp ?? 0 }))
  365. );
  366. if (loading) {
  367. return (
  368. <div className="h-screen flex items-center justify-center" style={{ background: 'var(--bg)' }}>
  369. <div className="flex items-center gap-3" style={{ color: 'var(--text-muted)' }}>
  370. <div className="w-5 h-5 rounded-full animate-spin"
  371. style={{ borderColor: '#6366F1', borderTopColor: 'transparent' }} />
  372. <span className="text-sm">Loading review…</span>
  373. </div>
  374. </div>
  375. );
  376. }
  377. if (!asset) return null;
  378. return (
  379. <div className="h-screen flex flex-col overflow-hidden" style={{ background: 'var(--bg)' }}>
  380. {/* ── Top bar ──────────────────────────────────────────── */}
  381. <header className="h-12 flex items-center px-4 gap-3 shrink-0"
  382. style={{ background: 'rgba(10,11,20,0.95)', borderBottom: '1px solid rgba(255,255,255,0.06)', zIndex: 50 }}>
  383. <button
  384. onClick={() => router.push(`/projects/${asset.projectId}`)}
  385. className="flex items-center gap-1.5 text-xs transition-colors shrink-0"
  386. style={{ color: 'var(--text-muted)' }}
  387. >
  388. <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  389. <path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
  390. </svg>
  391. <span className="hidden sm:inline">Back</span>
  392. </button>
  393. <div className="w-px h-5 shrink-0" style={{ background: 'rgba(255,255,255,0.08)' }} />
  394. <div className="flex-1 min-w-0">
  395. <h1 className="text-xs font-medium truncate" style={{ color: 'var(--text)' }}>{asset.title}</h1>
  396. </div>
  397. <span className="text-xs hidden sm:inline shrink-0" style={{ color: 'var(--text-subtle)' }}>
  398. {asset.project?.name}
  399. </span>
  400. <div className="w-px h-5 shrink-0" style={{ background: 'rgba(255,255,255,0.08)' }} />
  401. {/* Download */}
  402. <a
  403. href={`${API_BASE}/uploads/${asset.filePath}`}
  404. download={asset.filename}
  405. className="flex items-center gap-1.5 text-xs px-2.5 py-1 rounded-md transition-all shrink-0"
  406. style={{ color: '#60A5FA', background: 'rgba(96,165,250,0.08)' }}
  407. title="Download original video"
  408. >
  409. <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  410. <path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
  411. </svg>
  412. <span className="hidden sm:inline">Download</span>
  413. </a>
  414. <div className="w-px h-5 shrink-0" style={{ background: 'rgba(255,255,255,0.08)' }} />
  415. {/* Compare mode toggle */}
  416. <button
  417. onClick={() => {
  418. if (compareMode) {
  419. handleExitCompare();
  420. } else {
  421. setShowComparePicker(true);
  422. if (token && asset) {
  423. assetsApi.list(token, asset.projectId).then(({ assets }) => {
  424. setProjectAssets(assets.filter(a => a.id !== assetId && a.transcodeStatus === 'COMPLETED'));
  425. }).catch(() => {});
  426. }
  427. }
  428. }}
  429. className={`flex items-center gap-1.5 text-xs px-2.5 py-1 rounded-md transition-all shrink-0 ${
  430. compareMode
  431. ? 'bg-indigo-600 text-white'
  432. : ''
  433. }`}
  434. style={!compareMode ? { color: '#818CF8', background: 'rgba(129,140,248,0.10)' } : {}}
  435. title="Side-by-side comparison"
  436. >
  437. <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  438. <path strokeLinecap="round" strokeLinejoin="round" d="M3.75 3.75v4.5m0-4.5h4.5m-4.5 0L9 9M3.75 20.25v-4.5m0 4.5h4.5m-4.5 0L9 15M20.25 3.75h-4.5m4.5 0v4.5m0-4.5L15 9m5.25 11.25h-4.5m4.5 0v-4.5m0 4.5L15 15" />
  439. </svg>
  440. <span className="hidden sm:inline">{compareMode ? 'Exit Compare' : 'Compare'}</span>
  441. </button>
  442. {/* Status selector */}
  443. <div className="relative shrink-0">
  444. <button
  445. onClick={() => setShowApproval(v => !v)}
  446. className="flex items-center gap-1.5 text-xs font-medium px-2.5 py-1 rounded-md transition-all"
  447. style={{ background: statusCfg.bgClass.replace('badge-', 'rgba(').replace('warning', '245,158,11,0.15)').replace('success', '34,197,94,0.15)').replace('danger', '239,68,68,0.15)'), color: statusCfg.colorClass }}
  448. >
  449. <span className={`status-dot ${statusCfg.dotClass}`} />
  450. <span className="hidden sm:inline">{statusCfg.label}</span>
  451. <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  452. <path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
  453. </svg>
  454. </button>
  455. {showApproval && (
  456. <>
  457. <div className="fixed inset-0 z-40" onClick={() => setShowApproval(false)} />
  458. <div className="absolute right-0 top-full mt-2 z-50 rounded-xl overflow-hidden"
  459. style={{ background: '#1E2030', border: '1px solid rgba(255,255,255,0.10)', boxShadow: 'var(--shadow-panel)', minWidth: '200px' }}>
  460. {Object.entries(STATUS_CONFIG).map(([key, cfg]) => (
  461. <button
  462. key={key}
  463. onClick={() => handleStatusUpdate(key)}
  464. disabled={updatingStatus}
  465. className="w-full flex items-center gap-2.5 px-4 py-2.5 text-xs transition-colors hover:bg-white/5"
  466. style={{ color: key === status ? cfg.colorClass : 'var(--text)' }}
  467. >
  468. <span className={`status-dot ${cfg.dotClass}`} />
  469. <span className="flex-1 text-left">{cfg.label}</span>
  470. {key === status && (
  471. <svg className="w-3.5 h-3.5" style={{ color: '#6366F1' }} fill="currentColor" viewBox="0 0 20 20">
  472. <path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
  473. </svg>
  474. )}
  475. </button>
  476. ))}
  477. </div>
  478. </>
  479. )}
  480. </div>
  481. </header>
  482. {/* ── Compare picker modal ─────────────────────────────────────────────── */}
  483. {showComparePicker && (
  484. <>
  485. <div className="fixed inset-0 z-50" onClick={() => setShowComparePicker(false)} />
  486. <div
  487. className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-50 rounded-2xl overflow-hidden w-full max-w-md"
  488. style={{ background: '#1E2030', border: '1px solid rgba(255,255,255,0.10)', boxShadow: 'var(--shadow-modal)' }}
  489. >
  490. <div className="px-5 py-4 flex items-center justify-between" style={{ borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
  491. <h2 className="text-sm font-semibold" style={{ color: 'var(--text)' }}>Select video to compare</h2>
  492. <button onClick={() => setShowComparePicker(false)} className="w-7 h-7 flex items-center justify-center rounded-lg transition-colors hover:bg-white/10"
  493. style={{ color: 'var(--text-muted)' }}>
  494. <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  495. <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
  496. </svg>
  497. </button>
  498. </div>
  499. <div className="p-2 max-h-80 overflow-y-auto">
  500. {projectAssets.length === 0 ? (
  501. <p className="text-sm text-center py-8" style={{ color: 'var(--text-muted)' }}>
  502. No other completed videos in this project.
  503. </p>
  504. ) : (
  505. projectAssets.map(a => (
  506. <button
  507. key={a.id}
  508. onClick={() => handleCompareSelect(a)}
  509. className="w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-left transition-colors hover:bg-white/5"
  510. >
  511. {a.thumbnail ? (
  512. <img src={`${API_BASE}/uploads/${a.thumbnail}`} className="w-16 h-10 rounded-lg object-cover shrink-0" alt={a.title} />
  513. ) : (
  514. <div className="w-16 h-10 rounded-lg shrink-0 flex items-center justify-center" style={{ background: 'rgba(255,255,255,0.06)' }}>
  515. <svg className="w-5 h-5" style={{ color: 'rgba(255,255,255,0.2)' }} fill="currentColor" viewBox="0 0 24 24">
  516. <path d="M8 5v14l11-7z" />
  517. </svg>
  518. </div>
  519. )}
  520. <div className="flex-1 min-w-0">
  521. <p className="text-sm font-medium truncate" style={{ color: 'var(--text)' }}>{a.title}</p>
  522. <p className="text-xs" style={{ color: 'var(--text-muted)' }}>
  523. {a.duration ? `${Math.floor(a.duration / 60)}:${Math.floor(a.duration % 60).toString().padStart(2, '0')}` : '—'}
  524. {' · '}
  525. {a.filename}
  526. </p>
  527. </div>
  528. </button>
  529. ))
  530. )}
  531. </div>
  532. </div>
  533. </>
  534. )}
  535. {/* ── Body ───────────────────────────────────────────── */}
  536. {/* Landscape: side-by-side | Portrait: stacked (video top, comments bottom) */}
  537. <div
  538. className="flex flex-1 overflow-hidden"
  539. style={isPortrait
  540. ? { flexDirection: 'column', overflowY: 'auto' }
  541. : { flexDirection: 'row' }}
  542. >
  543. {/* Video area */}
  544. <div
  545. className="overflow-y-auto p-3 sm:p-4 flex flex-col gap-3 min-w-0"
  546. style={isPortrait
  547. ? { flex: 'none', width: '100%', minHeight: '45vh' }
  548. : { flex: 1, overflowY: 'auto' }}
  549. >
  550. {/* ── Side-by-side compare layout ───────────────────────── */}
  551. {compareMode ? (
  552. <div className="flex gap-2 w-full flex-1 min-h-0">
  553. {/* Main video + its comments */}
  554. <div className="flex-1 min-w-0 flex flex-col gap-0 min-h-0">
  555. {/* Annotation toggle */}
  556. <div className="flex items-center gap-2 mb-1 px-1">
  557. <button
  558. onClick={() => setShowMainAnnotations(v => !v)}
  559. className="flex items-center gap-1.5 text-[11px] px-2 py-1 rounded-md transition-colors"
  560. style={showMainAnnotations
  561. ? { background: 'rgba(99,102,241,0.15)', color: '#818CF8' }
  562. : { background: 'rgba(255,255,255,0.05)', color: 'var(--text-subtle)' }}
  563. title={showMainAnnotations ? 'Hide annotations' : 'Show annotations'}
  564. >
  565. <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  566. <path strokeLinecap="round" strokeLinejoin="round" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
  567. </svg>
  568. Annot.
  569. </button>
  570. </div>
  571. <div className="text-xs mb-1 px-1 truncate" style={{ color: 'rgba(255,255,255,0.5)' }}>
  572. {asset.title}
  573. </div>
  574. <div className="flex-1 min-h-0 flex flex-col gap-0">
  575. <VideoPlayer
  576. src={videoUrl}
  577. mimeType={asset.mimeType}
  578. fps={fps}
  579. comments={showMainAnnotations ? allComments : []}
  580. visibleAnnotations={showMainAnnotations ? visibleAnnotations : []}
  581. drawMode={drawMode}
  582. drawTool={drawTool}
  583. drawColor={drawColor}
  584. onDrawModeChange={setDrawMode}
  585. onDrawToolChange={setDrawTool}
  586. onDrawColorChange={setDrawColor}
  587. pendingStrokes={pendingStrokes}
  588. onStrokeComplete={handleStrokeComplete}
  589. onTimeUpdate={handleTimeUpdate}
  590. onCommentClick={handleCommentSeek}
  591. onPlayingChange={setPlaying}
  592. onTimelineSeek={handleTimeUpdate}
  593. externalCurrentTime={currentTime}
  594. externalPlaying={playing}
  595. videoRef={mainVideoRef}
  596. onPrevComment={handlePrevComment}
  597. onNextComment={handleNextComment}
  598. thumbnailSrc={videoUrl}
  599. thumbnailMimeType={asset.mimeType}
  600. />
  601. {/* Comments below main video — full available height */}
  602. <div className="mt-2 rounded-xl flex-1 min-h-0 flex flex-col overflow-hidden" style={{ background: 'rgba(10,11,20,0.80)', border: '1px solid rgba(255,255,255,0.06)' }}>
  603. <div className="px-3 py-2 shrink-0 flex items-center gap-2" style={{ borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
  604. <span className="text-xs font-medium" style={{ color: 'var(--text)' }}>
  605. Comments
  606. </span>
  607. <span className="text-xs px-1.5 py-0.5 rounded-full" style={{ background: 'rgba(255,255,255,0.06)', color: 'var(--text-muted)' }}>
  608. {visibleComments.length}
  609. </span>
  610. <span className="font-mono text-[11px] ml-auto" style={{ color: '#818CF8' }}>
  611. {formatTimecode(currentTime, fps, asset?.duration ?? 0)}
  612. </span>
  613. </div>
  614. <div className="flex-1 overflow-y-auto scroll-area">
  615. {visibleComments.length === 0 ? (
  616. <p className="text-xs text-center py-4" style={{ color: 'var(--text-muted)' }}>No comments</p>
  617. ) : (
  618. visibleComments.map(comment => (
  619. <div key={comment.id} className="px-3 py-2.5 flex items-start gap-2" style={{ borderBottom: '1px solid rgba(255,255,255,0.04)' }}>
  620. <Avatar name={comment.user?.name ?? 'U'} size="xs" />
  621. <div className="flex-1 min-w-0">
  622. <div className="flex items-center gap-1.5 mb-0.5">
  623. <span className="text-[11px] font-medium" style={{ color: 'var(--text)' }}>{comment.user?.name ?? 'Unknown'}</span>
  624. {comment.timestamp != null && (
  625. <span className="text-[10px] font-mono px-1 rounded" style={{ background: 'rgba(99,102,241,0.10)', color: '#818CF8' }}>
  626. {formatTimecode(comment.timestamp, fps, asset?.duration ?? 0)}
  627. </span>
  628. )}
  629. </div>
  630. <p className="text-[11px] leading-relaxed" style={{ color: 'var(--text-muted)' }}>{comment.content}</p>
  631. </div>
  632. </div>
  633. ))
  634. )}
  635. </div>
  636. </div>
  637. </div>
  638. </div>
  639. {/* Compare video + its comments — only show when durations match */}
  640. {compareAsset && !compareMismatch && (
  641. <div className="flex-1 min-w-0 flex flex-col gap-0 min-h-0">
  642. {/* Annotation toggle */}
  643. <div className="flex items-center gap-2 mb-1 px-1">
  644. <button
  645. onClick={() => setShowCompareAnnotations(v => !v)}
  646. className="flex items-center gap-1.5 text-[11px] px-2 py-1 rounded-md transition-colors"
  647. style={showCompareAnnotations
  648. ? { background: 'rgba(99,102,241,0.15)', color: '#818CF8' }
  649. : { background: 'rgba(255,255,255,0.05)', color: 'var(--text-subtle)' }}
  650. title={showCompareAnnotations ? 'Hide annotations' : 'Show annotations'}
  651. >
  652. <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  653. <path strokeLinecap="round" strokeLinejoin="round" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
  654. </svg>
  655. Annot.
  656. </button>
  657. </div>
  658. <div className="text-xs mb-1 px-1 truncate" style={{ color: 'rgba(255,255,255,0.5)' }}>
  659. {compareAsset.title}
  660. </div>
  661. <div className="flex-1 min-h-0 flex flex-col gap-0">
  662. <VideoPlayer
  663. src={compareAsset.hlsPath ? `${API_BASE}/uploads${compareAsset.hlsPath}` : `${API_BASE}/uploads/${compareAsset.filePath}`}
  664. mimeType={compareAsset.mimeType}
  665. fps={compareAsset.fps ?? 30}
  666. comments={showCompareAnnotations ? compareComments : []}
  667. visibleAnnotations={showCompareAnnotations ? compareVisibleAnnotations : []}
  668. drawMode={false}
  669. drawTool={drawTool}
  670. drawColor={drawColor}
  671. onDrawModeChange={() => {}}
  672. onDrawToolChange={() => {}}
  673. onDrawColorChange={() => {}}
  674. pendingStrokes={[]}
  675. onStrokeComplete={() => {}}
  676. onTimeUpdate={() => {}}
  677. onCommentClick={() => {}}
  678. isComparePlayer={true}
  679. externalCurrentTime={currentTime}
  680. externalPlaying={playing}
  681. thumbnailSrc={compareAsset.hlsPath ? `${API_BASE}/uploads${compareAsset.hlsPath}` : `${API_BASE}/uploads/${compareAsset.filePath}`}
  682. thumbnailMimeType={compareAsset.mimeType}
  683. />
  684. {/* Comments below compare video — full available height */}
  685. <div className="mt-2 rounded-xl flex-1 min-h-0 flex flex-col overflow-hidden" style={{ background: 'rgba(10,11,20,0.80)', border: '1px solid rgba(255,255,255,0.06)' }}>
  686. <div className="px-3 py-2 shrink-0 flex items-center gap-2" style={{ borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
  687. <span className="text-xs font-medium" style={{ color: 'var(--text)' }}>
  688. Comments
  689. </span>
  690. <span className="text-xs px-1.5 py-0.5 rounded-full" style={{ background: 'rgba(255,255,255,0.06)', color: 'var(--text-muted)' }}>
  691. {compareVisibleComments.length}
  692. </span>
  693. </div>
  694. <div className="flex-1 overflow-y-auto scroll-area">
  695. {compareVisibleComments.length === 0 ? (
  696. <p className="text-xs text-center py-4" style={{ color: 'var(--text-muted)' }}>No comments</p>
  697. ) : (
  698. compareVisibleComments.map(comment => (
  699. <div key={comment.id} className="px-3 py-2.5 flex items-start gap-2" style={{ borderBottom: '1px solid rgba(255,255,255,0.04)' }}>
  700. <Avatar name={comment.user?.name ?? 'U'} size="xs" />
  701. <div className="flex-1 min-w-0">
  702. <div className="flex items-center gap-1.5 mb-0.5">
  703. <span className="text-[11px] font-medium" style={{ color: 'var(--text)' }}>{comment.user?.name ?? 'Unknown'}</span>
  704. {comment.timestamp != null && (
  705. <span className="text-[10px] font-mono px-1 rounded" style={{ background: 'rgba(99,102,241,0.10)', color: '#818CF8' }}>
  706. {formatTimecode(comment.timestamp, fps, asset?.duration ?? 0)}
  707. </span>
  708. )}
  709. </div>
  710. <p className="text-[11px] leading-relaxed" style={{ color: 'var(--text-muted)' }}>{comment.content}</p>
  711. </div>
  712. </div>
  713. ))
  714. )}
  715. </div>
  716. </div>
  717. </div>
  718. </div>
  719. )}
  720. </div>
  721. ) : (
  722. /* ── Normal single-video layout ─────────────────────────── */
  723. <VideoPlayer
  724. src={videoUrl}
  725. mimeType={asset.mimeType}
  726. fps={fps}
  727. comments={allComments}
  728. visibleAnnotations={visibleAnnotations}
  729. drawMode={drawMode}
  730. drawTool={drawTool}
  731. drawColor={drawColor}
  732. onDrawModeChange={setDrawMode}
  733. onDrawToolChange={setDrawTool}
  734. onDrawColorChange={setDrawColor}
  735. pendingStrokes={pendingStrokes}
  736. onStrokeComplete={handleStrokeComplete}
  737. onTimeUpdate={handleTimeUpdate}
  738. onCommentClick={handleCommentSeek}
  739. onPlayingChange={setPlaying}
  740. videoRef={mainVideoRef}
  741. onPrevComment={handlePrevComment}
  742. onNextComment={handleNextComment}
  743. thumbnailSrc={videoUrl}
  744. thumbnailMimeType={asset.mimeType}
  745. />
  746. )}
  747. {/* ── Compare mismatch warning ─────────────────────────── */}
  748. {compareMode && compareMismatch && (
  749. <div className="rounded-xl px-4 py-3 text-xs flex items-center gap-3"
  750. style={{ background: 'rgba(251,191,36,0.10)', border: '1px solid rgba(251,191,36,0.25)', color: '#FCD34D' }}>
  751. <svg className="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  752. <path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
  753. </svg>
  754. <span className="flex-1">{compareMismatch}</span>
  755. <button
  756. onClick={handleExitCompare}
  757. className="shrink-0 px-2 py-1 rounded-md transition-colors"
  758. style={{ background: 'rgba(251,191,36,0.15)', color: '#FCD34D' }}
  759. >
  760. Cancel
  761. </button>
  762. </div>
  763. )}
  764. {/* Transcode status overlay — shown when video is not ready */}
  765. {transcodeCfg && asset.transcodeStatus !== 'COMPLETED' && (
  766. <div className="mt-3 rounded-xl p-4 flex items-center gap-4"
  767. style={{ background: transcodeCfg.bg, border: `1px solid ${transcodeCfg.color}30` }}>
  768. {transcodeCfg.spinner ? (
  769. <div className="w-8 h-8 rounded-full animate-spin shrink-0"
  770. style={{ borderColor: transcodeCfg.color, borderTopColor: 'transparent', borderWidth: '2.5px' }} />
  771. ) : asset.transcodeStatus === 'FAILED' ? (
  772. <div className="w-8 h-8 rounded-full flex items-center justify-center shrink-0"
  773. style={{ background: 'rgba(248,113,113,0.15)' }}>
  774. <svg className="w-4 h-4" style={{ color: '#F87171' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  775. <path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
  776. </svg>
  777. </div>
  778. ) : (
  779. <div className="w-8 h-8 rounded-full flex items-center justify-center shrink-0"
  780. style={{ background: 'rgba(251,191,36,0.15)' }}>
  781. <svg className="w-4 h-4" style={{ color: '#FBBF24' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  782. <path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
  783. </svg>
  784. </div>
  785. )}
  786. <div className="flex-1 min-w-0">
  787. <div className="flex items-center gap-2 mb-1">
  788. <span className="text-sm font-medium" style={{ color: transcodeCfg.color }}>
  789. {transcodeCfg.label}
  790. </span>
  791. {asset.transcodeStatus === 'PROCESSING' && asset.transcodeProgress > 0 && (
  792. <span className="text-xs font-mono" style={{ color: transcodeCfg.color }}>
  793. {asset.transcodeProgress}%
  794. </span>
  795. )}
  796. </div>
  797. {asset.transcodeStatus === 'PROCESSING' && (
  798. <div className="w-full h-1 rounded-full overflow-hidden" style={{ background: 'rgba(255,255,255,0.08)' }}>
  799. <div
  800. className="h-full rounded-full transition-all duration-500"
  801. style={{ width: `${asset.transcodeProgress}%`, background: transcodeCfg.color }}
  802. />
  803. </div>
  804. )}
  805. {asset.transcodeStatus === 'FAILED' && asset.transcodeError && (
  806. <p className="text-xs mt-1" style={{ color: '#F87171' }}>
  807. {asset.transcodeError}
  808. </p>
  809. )}
  810. {asset.transcodeStatus === 'UNSUPPORTED_CODEC' && (
  811. <p className="text-xs mt-1" style={{ color: '#FB923C' }}>
  812. {asset.codec ? `Source codec "${asset.codec.toUpperCase()}" — will re-encode to H.264/AAC` : 'Re-encoding to browser-compatible format…'}
  813. </p>
  814. )}
  815. {asset.transcodeStatus === 'PROCESSING' && asset.codec && (
  816. <p className="text-xs mt-1" style={{ color: '#94A3B8' }}>
  817. Converting from {asset.codec.toUpperCase()} → H.264/AAC
  818. </p>
  819. )}
  820. {asset.transcodeStatus === 'UPLOADING' && (
  821. <p className="text-xs mt-1" style={{ color: '#94A3B8' }}>
  822. Video uploaded — queued for processing
  823. </p>
  824. )}
  825. </div>
  826. </div>
  827. )}
  828. {/* Keyboard shortcuts */}
  829. {!compareMode && (
  830. <div className="flex flex-wrap gap-3 text-xs shrink-0 hidden sm:flex" style={{ color: 'var(--text-subtle)' }}>
  831. <span><kbd className="px-1.5 py-0.5 rounded text-[10px]" style={{ background: 'rgba(255,255,255,0.06)' }}>Space</kbd> play/pause</span>
  832. <span><kbd className="px-1.5 py-0.5 rounded text-[10px]" style={{ background: 'rgba(255,255,255,0.06)' }}>←</kbd><kbd className="px-1.5 py-0.5 rounded text-[10px] ml-0.5" style={{ background: 'rgba(255,255,255,0.06)' }}>→</kbd> ±1 frame <kbd className="px-1.5 py-0.5 rounded text-[10px]" style={{ background: 'rgba(255,255,255,0.06)' }}>⇧←</kbd><kbd className="px-1.5 py-0.5 rounded text-[10px] ml-0.5" style={{ background: 'rgba(255,255,255,0.06)' }}>⇧→</kbd> ±1s</span>
  833. <span><kbd className="px-1.5 py-0.5 rounded text-[10px]" style={{ background: 'rgba(255,255,255,0.06)' }}>C</kbd> draw mode</span>
  834. <span><kbd className="px-1.5 py-0.5 rounded text-[10px]" style={{ background: 'rgba(255,255,255,0.06)' }}>Esc</kbd> exit draw</span>
  835. <span className="font-mono text-[11px]">{formatTimecode(currentTime, fps, asset?.duration ?? 0)}</span>
  836. </div>
  837. )}
  838. </div>
  839. {/* Resize handle — visible grip bar with 3-dot pattern, wider hit area */}
  840. {!isPortrait && !compareMode && !commentPanelCollapsed && (
  841. <div
  842. onPointerDown={handleResizeStart}
  843. className="shrink-0 group relative cursor-col-resize select-none"
  844. style={{ width: 12 }}
  845. title="Drag to resize"
  846. >
  847. {/* Invisible wide hit area (wider than visual) */}
  848. <div className="absolute inset-y-0" style={{ width: 24, left: -6 }} />
  849. {/* Visual grip bar */}
  850. <div className="absolute inset-y-0 left-1/2 -translate-x-1/2 flex flex-col items-center justify-center gap-1.5" style={{ width: 2 }}>
  851. {[0, 1, 2].map(i => (
  852. <div
  853. key={i}
  854. className="w-1 rounded-full transition-colors"
  855. style={{
  856. height: 16,
  857. background: 'rgba(255,255,255,0.18)',
  858. }}
  859. />
  860. ))}
  861. </div>
  862. {/* Highlight on drag */}
  863. <div
  864. className="absolute inset-y-0 left-1/2 -translate-x-1/2 opacity-0 group-hover:opacity-100 transition-opacity"
  865. style={{ width: 2, background: 'rgba(99,102,241,0.5)' }}
  866. />
  867. </div>
  868. )}
  869. {/* Floating expand button when panel is collapsed */}
  870. {!isPortrait && !compareMode && commentPanelCollapsed && (
  871. <button
  872. onClick={() => setCommentPanelCollapsed(false)}
  873. className="shrink-0 flex items-center justify-center w-8 self-stretch rounded-l-lg transition-all hover:bg-white/10 active:scale-95"
  874. style={{ background: 'rgba(10,11,20,0.90)', borderLeft: '1px solid rgba(255,255,255,0.06)' }}
  875. title="Expand comments panel"
  876. >
  877. <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} style={{ color: 'var(--text-muted)' }}>
  878. <path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
  879. </svg>
  880. </button>
  881. )}
  882. {/* ── Comment panel — hidden in compare mode (comments are below each video) ── */}
  883. {!compareMode && (
  884. <div
  885. ref={panelRef}
  886. className={`flex flex-col shrink-0 transition-all duration-300 ease-in-out ${commentPanelCollapsed && !isPortrait ? 'comment-panel-collapsed' : ''}`}
  887. style={isPortrait
  888. ? {
  889. flex: 1,
  890. width: '100%',
  891. minHeight: '55vh',
  892. background: 'rgba(10,11,20,0.98)',
  893. borderTop: '1px solid rgba(255,255,255,0.06)',
  894. }
  895. : {
  896. width: panelWidth,
  897. background: 'rgba(10,11,20,0.98)',
  898. borderLeft: '1px solid rgba(255,255,255,0.06)',
  899. }}
  900. >
  901. {/* Panel header */}
  902. <div className="px-3 sm:px-4 py-2.5 sm:py-3 flex items-center justify-between shrink-0"
  903. style={{ borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
  904. <div className="flex items-center gap-2">
  905. <h2 className="text-[13px] sm:text-sm font-semibold" style={{ color: 'var(--text)' }}>Comments</h2>
  906. <span className="text-xs px-1.5 py-0.5 rounded-full"
  907. style={{ background: 'rgba(255,255,255,0.06)', color: 'var(--text-muted)' }}>
  908. {comments.length}
  909. </span>
  910. </div>
  911. <div className="flex items-center gap-2">
  912. <span className="font-mono text-[11px] sm:text-xs hidden sm:inline" style={{ color: '#818CF8' }}>
  913. {formatTimecode(currentTime, fps, asset?.duration ?? 0)}
  914. </span>
  915. <button
  916. onClick={() => setShowResolved(v => !v)}
  917. className={`text-[11px] px-2 py-0.5 rounded-md transition-colors ${showResolved ? 'bg-indigo-600 text-white' : ''}`}
  918. style={!showResolved ? { background: 'rgba(255,255,255,0.06)', color: 'var(--text-muted)' } : {}}
  919. >
  920. {showResolved ? 'Hide resolved' : 'Show resolved'}
  921. </button>
  922. <button
  923. onClick={() => setCommentPanelCollapsed(v => !v)}
  924. className="text-[11px] px-2 py-0.5 rounded-md transition-colors"
  925. style={commentPanelCollapsed
  926. ? { background: 'rgba(99,102,241,0.20)', color: '#818CF8' }
  927. : { background: 'rgba(255,255,255,0.06)', color: 'var(--text-muted)' }}
  928. title={commentPanelCollapsed ? 'Expand comments panel' : 'Collapse comments panel'}
  929. >
  930. <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  931. {commentPanelCollapsed ? (
  932. // Chevron right — panel is collapsed to the right
  933. <path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
  934. ) : (
  935. // Chevron left — panel is expanded
  936. <path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
  937. )}
  938. </svg>
  939. </button>
  940. {compareMode && (
  941. <span className="text-[11px] px-2 py-0.5 rounded-md" style={{ background: 'rgba(99,102,241,0.15)', color: '#818CF8' }}>
  942. Compare mode
  943. </span>
  944. )}
  945. </div>
  946. </div>
  947. {/* Drawing mode banner */}
  948. {drawMode && (
  949. <div className="px-4 py-2 shrink-0 flex items-center gap-2"
  950. style={{ background: 'rgba(59,130,246,0.12)', borderBottom: '1px solid rgba(59,130,246,0.2)' }}>
  951. <svg className="w-4 h-4 shrink-0" style={{ color: '#818CF8' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  952. <path strokeLinecap="round" strokeLinejoin="round" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
  953. </svg>
  954. <span className="text-xs flex-1" style={{ color: '#818CF8' }}>
  955. {annotatingComment
  956. ? `Drawing annotation on "${annotatingComment.user?.name}" — ${pendingStrokes.length}/${MAX_ANNOTATIONS} strokes`
  957. : `Drawing on video — ${pendingStrokes.length}/${MAX_ANNOTATIONS} strokes`}
  958. </span>
  959. <div className="flex items-center gap-1.5">
  960. <button
  961. onClick={handleUndoAnnotations}
  962. className="text-xs px-2 py-0.5 rounded transition-colors"
  963. style={{ background: 'rgba(239,68,68,0.15)', color: '#FCA5A5' }}
  964. >
  965. Undo all
  966. </button>
  967. <button
  968. onClick={handleSaveAnnotations}
  969. disabled={submitting || pendingStrokes.length === 0}
  970. className="text-xs px-2 py-0.5 rounded transition-colors disabled:opacity-40"
  971. style={{ background: 'rgba(34,197,94,0.15)', color: '#86EFAC' }}
  972. >
  973. {submitting ? 'Saving…' : 'Save'}
  974. </button>
  975. </div>
  976. </div>
  977. )}
  978. {/* Comment list */}
  979. <div className="flex-1 overflow-y-auto scroll-area">
  980. {visibleComments.length === 0 ? (
  981. <div className="flex flex-col items-center justify-center py-16 px-4 text-center">
  982. <div className="w-12 h-12 rounded-2xl flex items-center justify-center mb-3"
  983. style={{ background: 'rgba(99,102,241,0.08)', border: '1px solid rgba(99,102,241,0.12)' }}>
  984. <svg className="w-6 h-6" style={{ color: '#6366F1' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
  985. <path strokeLinecap="round" strokeLinejoin="round" d="M8.625 12a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H12m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 01-2.555-.337A5.972 5.972 0 015.41 20.97a5.969 5.969 0 01-.474-.065 4.48 4.48 0 00.978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25z" />
  986. </svg>
  987. </div>
  988. <p className="text-sm font-medium mb-1" style={{ color: 'var(--text)' }}>No comments yet</p>
  989. <p className="text-xs leading-relaxed" style={{ color: 'var(--text-muted)' }}>
  990. Add a comment below or click <strong>Add annotation</strong> on an existing comment
  991. </p>
  992. </div>
  993. ) : (
  994. <div>
  995. {visibleComments.map(comment => (
  996. <CommentItem
  997. key={comment.id}
  998. comment={comment}
  999. currentUserId={user?.id ?? ''}
  1000. fps={fps}
  1001. duration={asset?.duration ?? 0}
  1002. canComment={canComment}
  1003. isProjectAdmin={isProjectAdmin}
  1004. isProjectOwner={isProjectOwner ?? false}
  1005. onTimestampClick={handleCommentSeek}
  1006. onReply={() => { setReplyTo(comment); }}
  1007. onResolve={(action) => handleResolve(comment.id, action)}
  1008. onRequestResolve={() => handleRequestResolve(comment.id)}
  1009. onDeleteSelf={() => handleDeleteComment(comment.id)}
  1010. onDelete={(id) => handleDeleteComment(id)}
  1011. onAddAnnotation={() => handleAddAnnotationClick(comment)}
  1012. onDeleteAnnotation={(anns) => handleDeleteAnnotation(comment.id, anns)}
  1013. onRestore={handleRestoreComment}
  1014. />
  1015. ))}
  1016. </div>
  1017. )}
  1018. </div>
  1019. {/* New comment / reply input */}
  1020. <div className="shrink-0 p-4"
  1021. style={{ borderTop: '1px solid rgba(255,255,255,0.06)', background: 'rgba(10,11,20,0.80)' }}>
  1022. {replyTo && (
  1023. <div className="flex items-center gap-2 mb-2 text-xs" style={{ color: 'var(--text-muted)' }}>
  1024. <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  1025. <path strokeLinecap="round" strokeLinejoin="round" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
  1026. </svg>
  1027. Replying to {replyTo.user?.name}
  1028. <button onClick={() => setReplyTo(null)} className="ml-auto" style={{ color: 'var(--text-subtle)' }}>
  1029. <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  1030. <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
  1031. </svg>
  1032. </button>
  1033. </div>
  1034. )}
  1035. {/* Pending strokes indicator */}
  1036. {pendingStrokes.length > 0 && (
  1037. <div className="flex items-center gap-2 mb-2 text-xs" style={{ color: '#818CF8' }}>
  1038. <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  1039. <path strokeLinecap="round" strokeLinejoin="round" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
  1040. </svg>
  1041. {pendingStrokes.length} stroke{pendingStrokes.length !== 1 ? 's' : ''} ready
  1042. {annotatingComment ? ` → annotation on "${annotatingComment.user?.name}"` : ' → will be saved as new comment'}
  1043. <button onClick={handleUndoAnnotations} className="ml-auto text-xs" style={{ color: '#FCA5A5' }}>Undo</button>
  1044. </div>
  1045. )}
  1046. <form
  1047. onSubmit={e => {
  1048. e.preventDefault();
  1049. if (newComment.trim() || pendingStrokes.length > 0) {
  1050. handleAddComment(newComment, currentTime, pendingStrokes.length > 0 ? pendingStrokes : undefined);
  1051. }
  1052. }}
  1053. className="flex gap-2"
  1054. >
  1055. <Avatar name={user?.name ?? 'U'} size="sm" />
  1056. <div className="flex-1 flex gap-2">
  1057. <textarea
  1058. className="input flex-1"
  1059. value={compareMode ? '' : newComment}
  1060. onChange={e => setNewComment(e.target.value)}
  1061. placeholder={compareMode ? 'Comments disabled in compare mode' : replyTo ? 'Write a reply…' : 'Add a comment…'}
  1062. disabled={compareMode}
  1063. readOnly={compareMode}
  1064. rows={1}
  1065. style={{ resize: 'none', overflow: 'hidden' }}
  1066. onKeyDown={e => {
  1067. if (e.key === 'Enter' && !e.shiftKey) {
  1068. e.preventDefault();
  1069. if (newComment.trim() || pendingStrokes.length > 0) {
  1070. handleAddComment(newComment, currentTime, pendingStrokes.length > 0 ? pendingStrokes : undefined);
  1071. }
  1072. }
  1073. }}
  1074. />
  1075. <button
  1076. type="submit"
  1077. disabled={submitting || (!newComment.trim() && pendingStrokes.length === 0)}
  1078. className="btn btn-primary btn-sm px-3"
  1079. >
  1080. {submitting ? (
  1081. <div className="w-3.5 h-3.5 rounded-full animate-spin"
  1082. style={{ borderColor: '#fff', borderTopColor: 'transparent' }} />
  1083. ) : (
  1084. <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  1085. <path strokeLinecap="round" strokeLinejoin="round" d="M6 12h12M6 12l4-4M6 12l4 4" />
  1086. </svg>
  1087. )}
  1088. </button>
  1089. </div>
  1090. </form>
  1091. </div>
  1092. </div>
  1093. )}
  1094. </div>
  1095. </div>
  1096. );
  1097. }
  1098. // ── CommentItem ─────────────────────────────────────────────────────────────
  1099. function CommentItem({
  1100. comment,
  1101. currentUserId,
  1102. fps,
  1103. duration,
  1104. canComment,
  1105. isProjectAdmin,
  1106. isProjectOwner,
  1107. onTimestampClick,
  1108. onReply,
  1109. onResolve,
  1110. onRequestResolve,
  1111. onDeleteSelf,
  1112. onDelete,
  1113. onAddAnnotation,
  1114. onDeleteAnnotation,
  1115. onRestore,
  1116. }: {
  1117. comment: Comment;
  1118. currentUserId: string;
  1119. fps: number;
  1120. duration: number;
  1121. canComment: boolean | undefined;
  1122. isProjectAdmin: boolean;
  1123. isProjectOwner: boolean;
  1124. onTimestampClick: (c: Comment) => void;
  1125. onReply: () => void;
  1126. onResolve: (action: 'approve' | 'reject') => void;
  1127. onRequestResolve: () => void;
  1128. onDeleteSelf: () => void;
  1129. onDelete: (id: string) => void;
  1130. onAddAnnotation: () => void;
  1131. onDeleteAnnotation: (annotations: AnnotationData[]) => void;
  1132. onRestore: (id: string) => void;
  1133. }) {
  1134. const isOwner = comment.userId === currentUserId;
  1135. const isCommentAuthor = comment.userId === currentUserId;
  1136. const name = comment.user?.name ?? 'Unknown';
  1137. const isReply = !!comment.parentId;
  1138. const annotations = comment.annotations ?? [];
  1139. const canAddMore = annotations.length < MAX_ANNOTATIONS;
  1140. const isDeleted = !!comment.deleted;
  1141. const canRestore = !isDeleted && (isProjectOwner || isProjectAdmin);
  1142. // Resolve state machine
  1143. const isResolved = comment.resolveStatus === 'RESOLVED';
  1144. const isPending = comment.resolveStatus === 'PENDING_APPROVAL';
  1145. const canApprove = isCommentAuthor || isProjectAdmin;
  1146. const canRequest = canComment && !isResolved && !isPending && !isCommentAuthor;
  1147. const canReopen = isResolved && canApprove;
  1148. return (
  1149. <div
  1150. className="p-4 animate-fade-in"
  1151. style={{
  1152. opacity: isDeleted ? 0.45 : isResolved ? 0.55 : 1,
  1153. paddingLeft: isReply ? '2.5rem' : undefined,
  1154. borderLeft: isDeleted ? '2px solid rgba(239,68,68,0.3)' : undefined,
  1155. }}
  1156. >
  1157. <div className="flex gap-2.5">
  1158. <Avatar name={name} size="sm" />
  1159. <div className="flex-1 min-w-0">
  1160. {/* Meta row */}
  1161. <div className="flex items-center gap-2 mb-1 flex-wrap">
  1162. <span className="text-xs font-medium" style={{ color: 'var(--text)' }}>{name}</span>
  1163. {comment.timestamp != null && (
  1164. <button
  1165. onClick={() => onTimestampClick(comment)}
  1166. className="text-xs px-1.5 py-0.5 rounded font-mono transition-colors hover:bg-indigo-600/20"
  1167. style={{ background: 'rgba(99,102,241,0.10)', color: '#818CF8', fontSize: '11px' }}
  1168. >
  1169. {formatTimecode(comment.timestamp, fps, duration)}
  1170. </button>
  1171. )}
  1172. {isPending && (
  1173. <span className="text-xs px-1.5 py-0.5 rounded"
  1174. style={{ background: 'rgba(251,191,36,0.12)', color: '#FCD34D' }}>
  1175. Pending approval
  1176. </span>
  1177. )}
  1178. {isResolved && (
  1179. <span className="text-xs px-1.5 py-0.5 rounded"
  1180. style={{ background: 'rgba(34,197,94,0.10)', color: '#86EFAC' }}>
  1181. Approved
  1182. </span>
  1183. )}
  1184. {isResolved && comment.resolvedBy && (
  1185. <span className="text-xs" style={{ color: 'var(--text-subtle)' }}>
  1186. by {comment.resolvedBy.name}
  1187. </span>
  1188. )}
  1189. <span className="text-xs ml-auto" style={{ color: 'var(--text-subtle)' }}>
  1190. {new Date(comment.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
  1191. </span>
  1192. </div>
  1193. {/* Annotation preview badges */}
  1194. {annotations.length > 0 && (
  1195. <div className="flex flex-wrap gap-1 mb-2">
  1196. {annotations.map((ann, i) => (
  1197. <div
  1198. key={i}
  1199. className="inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded"
  1200. style={{ background: `${ann.color}20`, color: ann.color, border: `1px solid ${ann.color}40` }}
  1201. >
  1202. <svg className="w-2.5 h-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  1203. <path strokeLinecap="round" strokeLinejoin="round" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
  1204. </svg>
  1205. {ann.type}
  1206. {isOwner && (
  1207. <button
  1208. onClick={() => {
  1209. const remaining = annotations.filter((_, j) => j !== i);
  1210. onDeleteAnnotation(remaining);
  1211. }}
  1212. className="ml-0.5 hover:opacity-70 transition-opacity"
  1213. title="Delete this annotation"
  1214. >
  1215. <svg className="w-2.5 h-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  1216. <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
  1217. </svg>
  1218. </button>
  1219. )}
  1220. </div>
  1221. ))}
  1222. </div>
  1223. )}
  1224. {/* Content */}
  1225. <p className="text-[13px] sm:text-sm leading-relaxed mb-2" style={{ color: 'var(--text-muted)' }}>
  1226. {comment.content}
  1227. </p>
  1228. {/* Actions */}
  1229. <div className="flex items-center gap-1">
  1230. {/* Restore button for soft-deleted comments — project owner/ADMIN only */}
  1231. {isDeleted && (isProjectOwner || isProjectAdmin) && (
  1232. <button
  1233. onClick={() => onRestore(comment.id)}
  1234. className="text-xs px-2 py-1 rounded-md transition-colors"
  1235. style={{ color: '#86EFAC', background: 'rgba(34,197,94,0.10)' }}
  1236. title="Restore this comment"
  1237. >
  1238. <svg className="w-3.5 h-3.5 inline-block mr-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  1239. <path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
  1240. </svg>
  1241. Restore
  1242. </button>
  1243. )}
  1244. {!isReply && !isDeleted && (
  1245. <button
  1246. onClick={onAddAnnotation}
  1247. disabled={!canAddMore}
  1248. className="text-xs px-2 py-1 rounded-md transition-colors disabled:opacity-30"
  1249. style={{ color: '#818CF8' }}
  1250. title={canAddMore ? `Add annotation (${annotations.length}/${MAX_ANNOTATIONS})` : `Max ${MAX_ANNOTATIONS} annotations reached`}
  1251. >
  1252. <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  1253. <path strokeLinecap="round" strokeLinejoin="round" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
  1254. </svg>
  1255. </button>
  1256. )}
  1257. {!isReply && !isDeleted && (
  1258. <button
  1259. onClick={onReply}
  1260. className="text-xs px-2 py-1 rounded-md transition-colors"
  1261. style={{ color: 'var(--text-muted)' }}
  1262. title="Reply"
  1263. >
  1264. <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  1265. <path strokeLinecap="round" strokeLinejoin="round" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
  1266. </svg>
  1267. </button>
  1268. )}
  1269. {!isReply && (
  1270. <button
  1271. onClick={onReply}
  1272. className="text-xs px-2 py-1 rounded-md transition-colors"
  1273. style={{ color: 'var(--text-muted)' }}
  1274. title="Reply"
  1275. >
  1276. <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  1277. <path strokeLinecap="round" strokeLinejoin="round" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
  1278. </svg>
  1279. </button>
  1280. )}
  1281. {/* Resolve / approval workflow buttons */}
  1282. {!isReply && !isDeleted && !isResolved && !isPending && (
  1283. <>
  1284. {canRequest ? (
  1285. <button
  1286. onClick={onRequestResolve}
  1287. className="text-xs px-2 py-1 rounded-md transition-colors"
  1288. style={{ color: '#6366F1' }}
  1289. title="Request resolve approval"
  1290. >
  1291. <svg className="w-3.5 h-3.5 inline-block mr-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  1292. <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
  1293. </svg>
  1294. Request resolve
  1295. </button>
  1296. ) : (
  1297. <span
  1298. className="text-xs px-2 py-1 opacity-30"
  1299. style={{ color: '#6366F1' }}
  1300. title={!canComment ? 'Viewers cannot request resolve' : isCommentAuthor ? 'Cannot resolve your own comment' : undefined}
  1301. >
  1302. <svg className="w-3.5 h-3.5 inline-block mr-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  1303. <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
  1304. </svg>
  1305. Request resolve
  1306. </span>
  1307. )}
  1308. </>
  1309. )}
  1310. {isPending && canApprove && !isReply && !isDeleted && (
  1311. <>
  1312. <button
  1313. onClick={() => onResolve('approve')}
  1314. className="text-xs px-2 py-1 rounded-md transition-colors"
  1315. style={{ color: '#86EFAC' }}
  1316. title={`Approve (by ${comment.requestedBy?.name ?? 'someone'})`}
  1317. >
  1318. <svg className="w-3.5 h-3.5 inline-block mr-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  1319. <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
  1320. </svg>
  1321. Approve
  1322. </button>
  1323. <button
  1324. onClick={() => onResolve('reject')}
  1325. className="text-xs px-2 py-1 rounded-md transition-colors"
  1326. style={{ color: '#FCA5A5' }}
  1327. title="Reject resolve request"
  1328. >
  1329. <svg className="w-3.5 h-3.5 inline-block mr-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  1330. <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
  1331. </svg>
  1332. Reject
  1333. </button>
  1334. </>
  1335. )}
  1336. {isPending && !canApprove && !isReply && !isDeleted && (
  1337. <span className="text-xs px-2 py-1 opacity-40" style={{ color: '#FCD34D' }} title="Awaiting approval">
  1338. <svg className="w-3.5 h-3.5 inline-block mr-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  1339. <path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
  1340. </svg>
  1341. Awaiting approval
  1342. </span>
  1343. )}
  1344. {canReopen && !isReply && !isDeleted && (
  1345. <button
  1346. onClick={() => onResolve('reject')}
  1347. className="text-xs px-2 py-1 rounded-md transition-colors"
  1348. style={{ color: '#86EFAC' }}
  1349. title="Reopen comment"
  1350. >
  1351. <svg className="w-3.5 h-3.5 inline-block mr-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  1352. <path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
  1353. </svg>
  1354. Reopen
  1355. </button>
  1356. )}
  1357. {isOwner && !isDeleted && (
  1358. <button
  1359. onClick={onDeleteSelf}
  1360. className="text-xs px-2 py-1 rounded-md transition-colors"
  1361. style={{ color: 'var(--text-subtle)' }}
  1362. title="Hide comment"
  1363. >
  1364. <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  1365. <path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
  1366. </svg>
  1367. </button>
  1368. )}
  1369. </div>
  1370. {/* Replies */}
  1371. {comment.replies && comment.replies.length > 0 && (
  1372. <div className="mt-3 space-y-3">
  1373. {comment.replies.map(reply => (
  1374. <ReplyItem
  1375. key={reply.id}
  1376. comment={reply}
  1377. isOwner={reply.userId === currentUserId}
  1378. onDelete={() => onDelete(reply.id)}
  1379. />
  1380. ))}
  1381. </div>
  1382. )}
  1383. </div>
  1384. </div>
  1385. </div>
  1386. );
  1387. }
  1388. // ── ReplyItem ──────────────────────────────────────────────────────────────
  1389. // Replies have no resolve, no annotation, no timestamp — just content + delete
  1390. function ReplyItem({
  1391. comment,
  1392. isOwner,
  1393. onDelete,
  1394. }: {
  1395. comment: Comment;
  1396. isOwner: boolean;
  1397. onDelete: (id: string) => void;
  1398. }) {
  1399. return (
  1400. <div className="flex gap-2.5 animate-fade-in">
  1401. <Avatar name={comment.user?.name ?? 'U'} size="sm" />
  1402. <div className="flex-1 min-w-0">
  1403. <div className="flex items-center gap-2 mb-0.5">
  1404. <span className="text-xs font-medium" style={{ color: 'var(--text)' }}>
  1405. {comment.user?.name ?? 'Unknown'}
  1406. </span>
  1407. <span className="text-xs ml-auto" style={{ color: 'var(--text-subtle)' }}>
  1408. {new Date(comment.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
  1409. </span>
  1410. </div>
  1411. <p className="text-sm leading-relaxed" style={{ color: 'var(--text-muted)' }}>
  1412. {comment.content}
  1413. </p>
  1414. {isOwner && (
  1415. <button
  1416. onClick={() => onDelete(comment.id)}
  1417. className="text-xs mt-1 transition-colors"
  1418. style={{ color: 'var(--text-subtle)' }}
  1419. title="Delete reply"
  1420. >
  1421. Delete
  1422. </button>
  1423. )}
  1424. </div>
  1425. </div>
  1426. );
  1427. }